iT邦幫忙

2024 iThome 鐵人賽

DAY 21
1
Modern Web

Vue 和 TypeScript 的最佳實踐:成為前端工程師的進階利器系列 第 21

Day 21: Vitest 和 @vue/test-utils 的基礎介紹:如何編寫單元測試

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20240924/20117461xmJbwGZ4k0.jpg

簡介

在現代前端開發中,單元測試是確保代碼質量和可靠性的關鍵部分。本文將介紹如何使用 Vitest 和 @vue/test-utils 為 Vue 3 應用程序編寫單元測試。我們將探討如何整合 Pinia store、Zod、Vee-Validate、composables 和 @vueuse/core 等概念到測試中。此外,我們還將討論如何將 Storybook、Playwright 和 happy-dom 與 Vitest 集成,以創建一個全面的測試環境。

步驟 1: 安裝必要的依賴

首先,我們需要安裝所有必要的依賴。在你的 Vue 3 項目目錄中運行以下命令:

bun add -D vitest @vue/test-utils happy-dom @vitejs/plugin-vue
# 這是手動裝 storybook bun add -D @storybook/vue3 @storybook/addon-essentials @storybook/testing-vue3
bunx storybook@latest init # 使用 storybook 官方提供的方法裝即可
bun add -D @playwright/test
bun add pinia @vueuse/core zod vee-validate @vee-validate/zod

備註: 這裡 @vitejs/plugin-vue 要升級版本,不然會有不相容的問題

步驟 2: 配置 Vitest

創建一個 vitest.config.ts 文件在你的項目根目錄,並添加以下配置:

import { defineConfig } from "vitest/config";
import vue from "@vitejs/plugin-vue";

export default defineConfig({
  plugins: [vue()],
  test: {
    globals: true,
    environment: "happy-dom",
    exclude: ['node_modules', 'dist', '.idea', '.git', '.cache'],
    outputFile: {
      json: "test-results.json"
    },
    coverage: {
      provider: "v8",
      reporter: ["html", "json", "text"],
      exclude: [
        'node_modules',
        'src/main.ts'
      ]
    }
  }
});

並在 package.json 加入 scripts

{
  // ... (省略)
    "scripts": {
    "dev": "vite",
    "build": "vue-tsc && vite build",
    "preview": "vite preview",
    "test": "vitest" // 加入這行
  },
  // ... (省略)
}

簡單小試身手一下檢查

import { describe, it, expect } from "vitest";

describe("main", () => {
  it("true", () => {
    expect(true).toBe(true);
  });
});

跑一下指令

bun run test

如果看到以下結果代表基本上安裝成功了

https://ithelp.ithome.com.tw/upload/images/20241005/20117461Ct34AN6mKi.png

步驟 3: 創建一個簡單的 Vue 組件和 Pinia store

讓我們創建一個簡單的計數器組件和相應的 store 來演示測試。

(檔案:src/stores/useCounterStore.ts)

import { shallowRef , computed } from "vue"
import { defineStore, acceptHMRUpdate } from "pinia";

export const useCounterStore = defineStore("userStore", () => {

  // state::

  const count = shallowRef<number>(0);

  // getter::

  const doubleCount = computed<number>(() => count.value * 2)
  // methods::

  const increment = (): void => {
    count.value++;
  };

  const decrement = (): void => {
    count.value--;
  };

  return {
    // state::
    count,
    // getters::
    doubleCount,
    // methods::
    increment,
    decrement,
  }
})

if (import.meta.hot) {
  import.meta.hot.accept(acceptHMRUpdate(useCounterStore, import.meta.hot));
}

src/components/Counter.vue:

<script setup lang="ts">
  import { storeToRefs } from 'pinia';
  import { useCounterStore } from '../stores/useCouterStore';
  const counterStore = useCounterStore();
  const { increment, decrement } = counterStore;
  const { count, doubleCount } = storeToRefs(counterStore);
</script>

<template>
  <div>
    <p>count : {{ count }}</p>
    <p>double count : {{ doubleCount }}</p>
    <button data-testid="increment" aria-label="click to increase count" @click="increment" border-none px-3 py-2 rounded-md cursor-pointer box-border text="hover:white" bg="blue-400 hover:blue-800">+</button>
    <button data-testid="decrement" aria-label="click to decrease count" @click="decrement" border-none px-3 py-2 rounded-md cursor-pointer box-border text="hover:white" bg="blue-400 hover:blue-800">-</button>
  </div>
</template>

步驟 4: 為組件使用 pinia 的狀態編寫單元測試

創建 src/components/Counter.spec.ts 文件:

import { describe, it, expect, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { setActivePinia, createPinia  } from 'pinia';
import Counter from './Counter.vue';

describe('Counter.vue', () => {
  beforeEach(() => {
    setActivePinia(createPinia());
  })

  it('render', () => {
    const wrapper = mount(Counter);
    expect(wrapper.text()).toContain('count : 0')
  });

  it('increments count when button is clicked', async () => {
    const wrapper = mount(Counter);
    await wrapper.find('[data-testid="increment"]').trigger('click');
    expect(wrapper.text()).toContain('count : 1')
  });

  it('decrement count when button is clicked', async () => {
    const wrapper = mount(Counter);
    await wrapper.find('[data-testid="decrement"]').trigger('click');
    expect(wrapper.text()).toContain('count : -1')
  });
});

一樣可以跑跑看,注意這裡我建議用 data-testid 將行測試,這樣比較可以錨定對象,前面組件我盡可能寫得簡單,也同時為了單元測試鋪路

步驟 5: 整合 Zod 和 Vee-Validate

讓我們創建一個使用 Zod 和 Vee-Validate 的表單組件,然後為它編寫測試。

(檔案:src/components/UserForm.vue)

<script setup lang="ts">
  import { useUserForm } from '../composables/useUserForm';
  import CustomInput from './CustomInput.vue';
  const wait = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));

  const {
    name,
    email,
    formSubmit,
    isSubmittingDisabled,
    errors
  } = useUserForm(async submitValue => {
    await wait(500);
    console.log(submitValue);
    return true;
  });
</script>

<template>
  <form @submit.prevent="formSubmit" role="user form" w="1/4 2xl:1/6" border="solid 1px gray-100" shadow-lg px-6 py-4 flex="~ col" gap-y-2>
    <CustomInput label="Name" placeholder="name" v-model="name" :error-message="errors.name" :disabled="isSubmittingDisabled" />
    <CustomInput label="Email" placeholder="email" v-model="email" :error-message="errors.email" :disabled="isSubmittingDisabled" />
    <button :disabled="isSubmittingDisabled" type="submit" aria-label="submit user form" border-none px-3 py-2 rounded-md cursor-pointer box-border text="disabled:gray-800 hover:white" bg="blue-400 disabled:gray-400 hover:blue-800">
      {{ isSubmittingDisabled ? 'Submitting...' : 'Submit' }}
    </button>
  </form>
</template>

補充:

(檔案:src/composables/useUserForm.ts)

import * as zod from "zod";
import { shallowRef } from "vue";
import { useThrottleFn } from "@vueuse/core";
import { useForm, useField } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod";

export const userSchema = zod
  .object({
    name: zod.string().min(1, "name is required"),
    email: zod.string().email(),
  });

export type UserSchema = zod.infer<typeof userSchema>;

export const useUserForm = (submitFn: (values: UserSchema) => Promise<boolean>, submitErrorFn?: () => void) => {
  const isSubmittingDisabled = shallowRef<boolean>(false);
  const validationSchema = toTypedSchema(userSchema);

  const initialValues: UserSchema = {
    name: "",
    email: "",
  };

  const { handleSubmit, isSubmitting, resetForm, errors } = useForm<UserSchema>({
    validationSchema,
    initialValues
  });

  const formSubmit = handleSubmit(
    useThrottleFn(async values  => {
      isSubmittingDisabled.value = true;
      const isSuccess = await submitFn(values);
      if (!isSuccess && submitErrorFn) {
        submitErrorFn();
      }
      isSubmittingDisabled.value = false;
    }, 800)
  );

  const { value: name } = useField<string>("name");
  const { value: email } = useField<string>("email");

  return {
    name,
    email,
    formSubmit,
    isSubmitting,
    isSubmittingDisabled,
    resetForm,
    errors
  };
};

export type UseUserForm = typeof useUserForm;

(檔案 : src/components/CustomInput.vue)

<script setup lang="ts">
  import { useId } from 'vue';

  const { id = useId(), isShowLabel = true, placeholder = '', errorMessage = '', disabled = false } = defineProps<{
    label: string;
    id?: string;
    isShowLabel?: boolean;
    placeholder?: string;
    errorMessage?: string;
    disabled?: boolean;
  }>();

  const errorID = useId();
  const modelValue = defineModel<string | number>({ default: '' });
</script>

<template>
  <div>
    <label v-show="isShowLabel" :for="id">{{ label }}</label>
    <input bg="disabled:gray-400" w-full px-2 py-1 rounded-md border="solid 1px gray-500" :placeholder :aria-describedby="errorMessage ? errorID : undefined" :id :disabled v-model="modelValue" />
    <span text="red-500 sm"  v-show="errorMessage" :id="errorID">{{ errorMessage }}</span>
  </div>
</template>

sample

現在,讓我們為這個組件編寫測試。

(檔案:src/components/UserForm.spec.ts)

import { describe, it, expect } from "vitest";
import { mount } from '@vue/test-utils';
import UserForm from './UserForm.vue'
import flushPromises from 'flush-promises';
import waitForExpect from 'wait-for-expect';

describe("UserForm.vue", () => {
  it("validate form not valid", async () => {
    const wrapper = mount(UserForm);
    const nameInput = wrapper.find<HTMLInputElement>('input[placeholder="Name"]');
    const emailInput = wrapper.find<HTMLInputElement>('input[placeholder="Email"]');

    await nameInput.setValue('j');
    await emailInput.setValue('1234');

    await wrapper.find('form').trigger('submit');
    await flushPromises();
    await waitForExpect(() => {
      expect(wrapper.text()).toContain('at least 2 characters');
      expect(wrapper.text()).toContain('not email format');

    });
  });

  it("validate form valid", async () => {
    const wrapper = mount(UserForm);
    await wrapper.find<HTMLInputElement>('input[placeholder="Name"]').setValue('hello');
    await wrapper.find<HTMLInputElement>('input[placeholder="Email"]').setValue('1234@gmail.com');
    await wrapper.find('form').trigger('submit');

    await flushPromises();
    await waitForExpect(() => {
      expect(wrapper.text()).not.toContain('at least 2 characters');
      expect(wrapper.text()).not.toContain('not email format');
    });
  });

});

補充 : vee-validate在撰寫測試時因為元件非同步的問題(Promise pending 沒有 resolve) 的問題
解決方法:

bun add -D flush-promises
bun add -D wait-for-expect

並按照我上方的寫法即可解決測試上的問題

步驟 6: 使用 @vueuse/core 的 composable

讓我們創建一個使用 @vueuse/core 的 composable 並為其編寫測試。

(檔案 : src/composables/useWindowSize.ts)

import { useWindowSize as vueUseWindowSize } from '@vueuse/core'

export function useWindowSize() {
  const { width, height } = vueUseWindowSize()
  
  const isSmallScreen = computed(() => width.value < 640)
  const isMediumScreen = computed(() => width.value >= 640 && width.value < 1024)
  const isLargeScreen = computed(() => width.value >= 1024)

  return {
    width,
    height,
    isSmallScreen,
    isMediumScreen,
    isLargeScreen,
  }
}

現在,讓我們為這個 composable 編寫測試。

src/composables/useWindowSize.spec.ts:

import { describe, it, expect, vi } from 'vitest'
import { useWindowSize } from './useWindowSize'
import { ref } from 'vue'

vi.mock('@vueuse/core', () => ({
  useWindowSize: vi.fn(() => ({
    width: ref(1024),
    height: ref(768),
  })),
}))

describe('useWindowSize', () => {
  it('correctly determines screen sizes', () => {
    const { isSmallScreen, isMediumScreen, isLargeScreen } = useWindowSize()

    expect(isSmallScreen.value).toBe(false)
    expect(isMediumScreen.value).toBe(false)
    expect(isLargeScreen.value).toBe(true)
  })
})

補充1: 整合 Storybook 展示元件

https://ithelp.ithome.com.tw/upload/images/20240924/20117461KkQ8Pc5LJ8.jpg
首先,初始化 Storybook:

npx storybook init

然後,為我們的 Counter 組件創建一個 story。

src/stories/Counter.stories.ts:

import type { Meta, StoryObj } from '@storybook/vue3'
import Counter from '../components/Counter.vue'
import { createPinia } from 'pinia'

const meta: Meta<typeof Counter> = {
  title: 'Components/Counter',
  component: Counter,
  decorators: [() => ({ template: '<div><story /></div>', setup: () => {createPinia()} })],
}

export default meta
type Story = StoryObj<typeof Counter>

export const Default: Story = {}

補充2: 整合 Playwright 進行 E2E 測試

https://ithelp.ithome.com.tw/upload/images/20240924/20117461Faj6gyOTLp.jpg

創建一個 Playwright 測試文件 tests/counter.spec.ts

import { test, expect } from '@playwright/test'

test('counter increments and decrements', async ({ page }) => {
  await page.goto('http://localhost:5173')  // 假設你的 app 運行在這個地址

  await expect(page.locator('text=Count: 0')).toBeVisible()

  await page.click('text=Increment')
  await expect(page.locator('text=Count: 1')).toBeVisible()

  await page.click('text=Decrement')
  await expect(page.locator('text=Count: 0')).toBeVisible()
})

步驟 9: 運行測試

package.json 中添加以下腳本:

{
  "scripts": {
    "test": "vitest",
    "test:ui": "vitest --ui",
    "test:coverage": "vitest run --coverage",
    "test:e2e": "playwright test"
  }
}

現在你可以運行以下命令來執行測試:

  • bun run test: 運行單元測試
  • bun run test:ui: 在 UI 模式下運行單元測試
  • bun run test:coverage: 運行單元測試並生成覆蓋率報告
  • bun run test:e2e: 運行 Playwright e2e 測試

結論

在本文中,我們學習了如何使用 Vitest 和 @vue/test-utils 為 Vue 3 應用程序編寫單元測試。我們成功整合了 Pinia store、Zod、Vee-Validate、@vueuse/core 等工具,並展示了如何測試使用這些工具的組件和 composables。

此外,我們還介紹了如何將 Storybook 用於組件開發,以及如何使用 Playwright 進行端到端測試。通過使用 happy-dom,我們能夠在 Node.js 環境中模擬 DOM,從而加速了測試的執行。

記住,編寫好的單元測試不僅可以幫助你捕獲錯誤,還可以提高代碼質量,並為重構提供信心。隨著你的應用程序變得越來越複雜,擁有一個強大的測試套件將變得越來越重要。

希望這個指南能夠幫助你開始在 Vue 3 項目中使用 Vitest 進行測試。隨著你的經驗增加,你可以探索更多高級的測試技術和策略。


上一篇
Day 20: 使用 TypeScript 與 UnoCSS 打造可重用的 UI 元件庫
下一篇
Day 22: 使用 TypeScript 和 Vitest 測試 Vue 組件的邊界情況
系列文
Vue 和 TypeScript 的最佳實踐:成為前端工程師的進階利器30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言